亿优百倍|商品数据服务缓存与代码优化
作者|邹庆楠&刘宇辉
编辑|林颖
供稿|Marketing Tech Team
本文共6958字,预计阅读时间15分钟
更多干货请关注“eBay技术荟”公众号
导 读
“亿优百倍”是eBay智能营销团队推出的系列文章,分享了在营销商品数据服务系统的架构、设计、代码方面的一些理解和研究。在上期的亿优百倍|商品数据服务TiDB性能优化(点击阅读)里,我们分享了对TiDB进行调优的方法,以提高了TiDB在eBay平台上使用的性能和稳定性。本期“亿优百倍”,我们分享了MIS中缓存层和代码层面的优化经验,以达到稳定的QPS/IPS目标。
1
MIS性能优化之缓存
1.1 横向扩展的极限
经过上一期进行TiDB相关的性能优化后,我们达到了约200K IPS的关口,但是离我们的目标500K IPS仍然有2.5倍的差距。根据上期描述的TiDB对横向扩展的初步评估发现,为了达到目标我们可能还要再对硬件进行3倍扩展。
这在eBay目前的环境下是非常困难的事情。因为,eBay的云基础架构正在从基于虚拟机转向Kubernetes容器化的过程中。公司更鼓励在容器化环境下做新的开发或扩容,因此任何大规模的虚拟机硬件扩容都需要经过层层审批。另一方面,TiDB在Kubernetes环境下(尤其是跨Kubernetes集群的部署和运维),在社区版还没有实现生产化。因此我们遇到了既不能在现有虚拟机环境下扩容,也不敢轻易采用Kubernetes的困境。
1.2 引入缓存层
然而,通过对业务场景的研究得知,我们的最高吞吐量需求的查询是一个典型的基于item ID查详情的Key-value(以下简称为KV)查询。因此,我们引入以NoSQL作为TiDB的缓存层的方案,这成为了我们提高整体系统性能的“银弹”。
我们选用了eBay自研的NuKV数据库来实现缓存层。NuKV是一个类似Memcache的分布式KV数据库。由于其相对简单的架构以及内存存储的特点,它的查询性能非常高,使得我们并不需要太大的NuKV集群,便可以实现500K IPS的查询需求。
1.2.1
缓存层设计要点
在引入缓存层后还是有几个要点需要考虑:
①怎么填充缓存?
②缓存的失效时间怎么设置和权衡?
③TiDB和NuKV的数据一致性怎么保障?
④多一层数据调用和传输是否会带来额外的性能损失?
⑤怎么衔接NuKV的KV查询到TiDB的SQL查询?
下面让我们详细解说这些方面的考虑和设计以保障系统的正确性和高性能。
1.2.2
缓存填充和失效策略
常见的缓存填充方式有两种。
一是比较被动的。当请求没有命中缓存时,会去主数据库拉取数据来填充缓存,并通过较短的缓存失效时间来保障主数据库和缓存的数据一致性。这种方案的优点是:缓存通常只会存储少量的“热”数据,因为少量,所以极限并发性能极高;但缺点是:有可能会出现因缓存不命中,在极端情况下会产生缓存击穿,而导致主数据库崩溃的风险。
另一种是比较主动的。每当有主数据库数据更新时,我们便更新缓存。因为缓存数据能被实时更新,所以缓存失效时间可以设置得非常长。这种方案的优点是:缓存和主数据库数据高度一致;但缺点是:要存储全量数据,导致大量”冷“数据浪费宝贵内存。另一个潜在问题是:如果出现缓存漏更新的情况(例如丢数据或IO异常等),就会产生因缓存失效时间过长而导致的数据不一致问题。
因为系统设计的初衷之一是要能捕捉关键商品的数据更新(比如价格变动),方案一即便把缓存失效时间设为只有几分钟,也破坏了我们原来对客户的数据延迟性保证,同时更短的失效时间也会带来更低的缓存命中率,从而降低系统整体性能。所以我们选择了方案二,但我们还需要解决如何保证TiDB和NuKV之间的数据一致性的问题。
1.2.3
主数据库TiDB和缓存层NuKV的数据一致性保障
我们的主要方案仍然是基于CDC(Change Data Capture)[1]的同步方案。和Oracle Golden Gate类似,TiDB有一个TiCDC的组件,会将对TiDB的更新事件发布出来。我们会有一个程序来消费这些更新事件,并同步更新到NuKV。
但是和同步Oracle到TiDB一样,因为纯流式处理Exactly Once实现的开销,我们没有使用。因此在一些异常情况时,还是可能存在丢数据、漏更新的情况。在“亿优百倍|商品数据服务百倍性能优化之路”(点击阅读)提到我们使用每日校正的方案来修补丢失数据。但是这个方案能工作取决于我们能拿到TiDB的全量数据(TiDB支持Spark批处理读取)并和真实数据做对比。然而NuKV(也包括很多主流NoSQL方案)并没有提供任何方案让我们拿到全量数据,因此无法做校正。
我们就此选择了一个折中的办法:通过把缓存失效时间设置为14天(可变参数)上下一个随机值,来强迫所有缓存数据一定会在14天左右失效,再通过命中主数据来填充。这样一定程度上保证了主数据库TiDB和缓存层NuKV数据的最终一致性。
1.2.4
缓存层架构
根据上文对一些核心模块和思考点的描述,我们的缓存层架构如下图所示。主要分为“读组件”和“写组件”。读组件负责读取缓存,或当缓存不命中时进行主数据库读取和填充;写组件主要通过TiCDC来同步更新缓存。
图1 缓存层架构示意图
(点击可查看大图)
2
MQL和服务层
前文说到我们引入了自己的SQL方言MQL来作为查询的接口。在简单场景下,我们的服务层(图1中的MQL Query Interface)只要下放SQL执行给TiDB就好了。但是我们现在引入了NuKV缓存,SQL语句需要翻译成NuKV的KV查询。语法解析和翻译是否会对性能造成影响?另外缓存的更新,数据库和服务之间的网络IO会对整体延迟和性能带来多大影响?
下文我们将回答这些问题,并分享在服务层发现的一些性能瓶颈及解决方案。为了方便理解我们先来简单介绍一下MQL。
MIS以MQL作为交互语言,MQL是MIS自定义的一个SQL方言。目前我们将MQL封装为一个独立运行的无状态服务,以对外暴露查询能力。
我们主要采用Apache Calcite[2]作为基础框架,提供了基础的SQL方言定制与解析以及关系代数的通用处理与优化框架。这套系统的特色不仅仅在于以MQL作为交互语言,在此之上我们还提供了数据业务结构感知的查询优化、对不同数据存储查询的统一抽象和多数据库系统间的联动查询。下图是MQL的整体处理流程。
图2 MQL结构
(点击可查看大图)
在优化器(Optimizer)中,我们会根据查询的特点(如点查、二级索引查询、分析性查询等)来选择合适的数据库或者数据库的组合。之后会针对查询的结构、数据库的特点以及数据本身业务逻辑提供的潜在能力,来补充优化查询。然后再转交给处理器(Processor)来执行查询。
而在一些具体的数据库之上,我们也做了一层简单的适配处理,使得在不丧失数据库本身通用性的前提下,提供一些额外的功能。目前的适配(如上图中的Adaptor组件)主要专注于:在TiDB与NuKV两路提供统一的UDF支持以及封装NuKV的访问接口,来使得我们能够以统一的接口来访问。
上述可以发现,MQL的整体处理流程足够强大,但也因此变得复杂。而复杂意味着潜在的高CPU消耗。在实际开发测试过程中,我们的确发现了不少新的性能问题,我们通过例行的性能测试来对新上线功能做Benchmark,把性能下降问题的归因尽量缩小。
3
MIS性能优化之服务代码优化
在系统不断迭代的过程中,因为新层的引入(如MQL),和新功能的开发(如在MQL中支持自定义UDF),让我们系统的整体性能有所下降。我们团队主要在提高IO和CPU执行效率两方面做了各种优化,来重新达到我们500K IPS的目标。
3.1 CPU优化
3.1.1
自动化SQL执行计划缓存
对MQL的解析、验证和优化是CPU消耗中的一大热点。一个4核8GB内存的机器上,在占用全部的CPU资源下,单个服务节点提供的最大吞吐是500QPS。但是因为CPU负载过高,此时请求延迟会高达200ms左右。经过进一步的CPU Profiling,我们发现CPU主要消耗在MQL的处理上。如下图所示,热点堆栈主要在使用Calcite进行语法解析上。
图3 CPU Profiling举例
(点击可查看大图)
通常这种情况在数据库系统中的应对方法是:在一个Session中提供SQL预处理的支持。这样可以使得用户能够先显式地创建一个查询模式,让数据库先行将前期的语句(statement)进行解析,基础优化工作完成后,只需要对这个模式传入不同的参数便能以较小的代价重复利用解析优化的结果。
但是在MIS项目中,我们发现实现预处理并没有那么容易。因为我们对用户提供的接口是基于HTTP协议的。如果实现预处理,我们就需要将执行计划(Execution Plan)进行序列化后,通过Session在节点间完成共享。而执行计划本身的内部状态(如SQL的语法树)是难以序列化的。
对此,我们实现了一种通过缓存来减少SQL解析的方案。用户的请求通常只会有有限种模式,所以大部分的MQL解析工作其实是没必要的重复执行。于是我们在MIS中引入了执行计划缓存的机制——将相同结构请求的验证与优化结果缓存起来。我们在解析完MQL后得来的抽象语法树 (Abstract Syntax Tree,AST)上做一层轻量处理之后,就能够获得一个查询的结构签名。这个结构签名可以用来作为缓存判定时的唯一标识。
图4 MQL缓存流程示意图
(点击可查看大图)
那么我们是如何处理MQL的AST来获得一个结构签名的呢。这个变换的核心在于参数剥离。简单来说,我们对MQL的AST做了如下变换:
图5 参数化举例
(点击可查看大图)
如上图所示,无论用户输入的MQL中item_id为多少,都可以将其映射到同一个请求模版上,于是我们就可以将这个结构作为Key来查找执行计划缓存。在缓存的执行计划结果中,会包含一个完全对应优化后SQL的字节码(Java bytecode),这段字节码是运行时生成并编译的,对外是一个实现了可枚举接口的类。所以后续执行计划的过程就是传入必要参数实例化并迭代。MQL由这段生成的代码来驱动对TiDB或者NuKv的访问,同时也驱动必要UDF的调用和相同模板变换语句的过滤。
然而还有一个问题,AST本身作为一个内部的树状结构,除了结构复杂以外,每一个节点上都会带许多处理时的临时标注信息。这使得直接对AST的相等判断存在了一定的难度。而解决这个问题的方式也很简单,直接将参数剥离后的AST重新渲染成SQL文本,并以此作为Key。Calcite本身提供的渲染AST到SQL的能力已经比较成熟了。当我们控制好渲染SQL的参数后,就能保证对同样的模式都能获得同样的SQL字符串。这种方法,在不失精度的前提下,剔除了AST上存在的不必要信息。而且,由于字符串的存储和相等判断是非常高效的,这也进一步提高了缓存的查询效率。另一个间接的好处是为调试提供了“便利”——SQL文本是非常方便人阅读的,通常看一眼缓存的Dump或在编辑器中查找一下文本就能定位到对应的缓存记录,然后进一步调试。
3.1.2
UDF扫描时的反射优化
在MIS的场景下,UDF(User Defined Function)目前还是由我们根据客户的需求实现的业务功能扩展。UDF的重要性在于它让我们能够以简单的接口和交互方式将许多复杂的和领域相关的计算包装供下游使用。在SQL语言基础上提供UDF,是强化MIS系统功能的重要入口,同时使用上也比较自然。
目前MIS中实现的UDF有计算税率、计算显示价格、渲染链接等。比如在上文执行计划缓存所给的例子中(如上图5),我们使用了rich_eek_info来获取电子商品的能源标识。而在这个简单的调用背后是我们整理出来的各个数据源的一个复杂计算的整合过程。又如cal_vat负责计算欧洲增值税,背后也有很多复杂的业务及制度逻辑。我们可以看到cal_vat是带参数的,VAT(Value Add Tax增值税)的计算需要site_id和user_id两个参数。这种传参的能力使得用户能在一定程度上定制计算过程。那么不管是想要哪个地区上的VAT还是想要一个请求调用多次cal_vat来计算多个user的VAT,都可以表达出来。
那么回到UDF支持的性能问题上,在最初的实现过程中,每一次UDF的调用,都会蕴含一次UDF的解析,CPU的消耗在性能测试下急剧提高。通过Profiling,我们发现,对UDF Class扫描和对具体的UDF方法处理时的反射操作是CPU耗时最为严重的地方之一,如下图ReflectUtil所在的位置。
图6 UDF反射的Profiling
(点击可查看大图)
这些反射操作并不是没有用处的,我们需要知道这些方法上的参数与返回值类型才能够在MQL处理时做出正确的校验和调用。不过这些反射也并不是每次请求时都需要执行一遍。UDF本身作为代码的一部分,在发布(Release)之后是不会动态变化的,所以我们将这些操作转为初始化的操作,只在程序启动的时候做一次,后续每一个请求直接使用预处理后的UDF,那么在请求的时候这部分反射操作就可以省去了。经过测试,调整之后的MQL服务性能与加UDF之前的几乎没有区别。
3.1.3
替换低效依赖库的实现
在具体UDF实现过程中,为了满足各种各样的业务需求,我们引入了一些eBay内部以及开源的依赖库。然而很多时候,某些依赖库并没有针对高性能的场景进行特殊的优化。这导致这些依赖库某些类的实现成为了MIS性能的瓶颈。
例如,为了实现欧洲商品税的复杂逻辑,我们引入了一个eBay内部算税的库。然而在第二天的例行性能测试中,我们发现最高吞吐量直接腰斩。如下图性能采样的热力图所示,红色区域清楚的表明了性能的瓶颈出现在依赖库的堆栈中(com/ebay/tax…)。顺着有性能问题的堆栈逐层分析,我们发现问题点在于:引入的依赖包使用了一个日志类来记录日志。该日志类在每次记录日志时会去调取当前的堆栈信息,而这一调用是非常消耗CPU时间的。而且日志记录又是一个频繁发生的动作,这直接导致了性能瓶颈的出现。
图7 依赖库的CPU Profiling
(点击可查看大图)
向依赖库的作者提出改进的要求虽然不失为一种解决方法,但是这个过程往往十分漫长,以至于无法满足MIS快速的迭代开发需求。而直接在依赖库源码上进行二次开发也不是一个长久兼容易维护的好方法。
因此,针对这一问题,我们使用MAVEN SHADE插件,通过仅重新实现有性能问题的依赖库类并重新打包替换,来快速地解决依赖库带来的性能瓶颈问题。借助MAVEN SHADE插件灵活的配置(如下图所示),这一解决方案有效地平衡了解决问题的效率和后期的维护成本,使得在依赖库作者解决性能问题前MIS能够满足对于性能的要求。
图8 Maven Shade打包替换类示意图
(点击可查看大图)
3.2 IO优化
3.2.1
使用Protobuf
在引入了NuKV做为缓存层后,为了优化服务和NuKV之间的IO,降低整条链路的数据传输量,我们选择Protobuf[3]作为序列化的方案。Protobuf做为业界广泛使用的序列化方案,提供了优秀的空间效率,经济的CPU占用以及卓越的稳定性。
相比于JSON(JavaScript Object Notation),Protobuf使用二进制存储原始数据,并且只使用一个数字做为字段的标识。在空间上面,二进制的存储和数字字段的标识更加紧凑且精确;另一方面,以数字字段标识构造的Map结构使得我们可以在极少的空间消耗下最大可能地保证了Schema变更前后的兼容性。
经过测试,与JSON相比,使用Protobuf来序列化数据使得我们在NuKV上的存储空间占用减少了66%(未经压缩的情况下)。若用JSON来存储一条记录(存了一个item_id下的所有SKU,每个SKU有70多个字段),则需要约2,000字节,而Protobuf只需要约600字节。
3.2.2
NuKV异步读写
使用NuKV作为缓存层,极大地减轻了TiDB的负载并提高了MIS整个系统的承载能力。然而缓存层的存在又引入新的问题:TiDB作为类SQL数据库天然支持数据的批量查询和更改,而NuKV作为一种键值对类型数据中间件、查询和更改只能按照主键逐一进行。这种访问模式上的差异引入了一个新的性能问题:如果我们仅仅是简单的将原有的批量查询和更新转化成在NuKV上的顺序同步查询和更改操作,那么整个查询和更新过程的延迟就将变成每一次NuKV上操作延迟的顺序叠加。而根据我们的观察,NuKV操作延迟在P99及P999上的表现较差,有时甚至能到50ms。这种延时的叠加在批量操作数据的条数增多时将变得非常可观,并会吞噬掉引入缓存层带来的性能改进,使得整体操作延迟在最坏情况下(P95,P99)表现较差。
以一个简单的例子(如下图所示)来说明这个问题。假设用户的批量查询中共包含10次单条查询,在系统中我们以顺序查询的方式访问NuKV,当查询过程中出现两次异常延迟时,总的批量查询延迟就会扩大到140ms。
图9 NuVK线性执行示意图
(点击可查看大图)
对于MIS,批量查询和更新(尤其是批量查询)是下游客户的一大主要需求。在某些情况下,一次批量查询会有大于20条数据。而我们观察到的(P95, P99)延迟表现不尽如人意。
为了解决这一问题并充分利用NuKV高并发的性能特点,我们将对NuKV的操作由同步改为异步,并在批量查询与更新时,将多个操作请求并发地提交给NuKV。如下图所示,通过这一优化,我们成功地将经过缓存层的批量查询由原来同步顺序执行时大于100ms的巨大延迟降低到50ms的低延迟。
图10 NuKV并行执行示意图
(点击可查看大图)
3.2.3
eBay商品图片URL的IO优化
由于MIS中存储的数据量非常庞大,任何一个可以减少存储空间的优化都是值得尝试的。其中一个例子便是我们对商品图片URL存储的优化。在eBay,我们用一个叫Zoom的系统来存储图片,eBay网站上的大部分图片都存储于该系统中,Zoom系统提供一个GUID来标识每个图片,并有定义清晰的API参数来对图片进行缩放、转换等。这样,我们只需要存储该图片在Zoom中的GUID,而非冗长的URL,具体的图片URL可以在之后通过固定的字符串模版动态渲染获得。
由于一个商品通常有好几张图片,经过实验评估,将这些Zoom系统中的图片URL缩减成GUID可以为我们节省了约1/3的整体存储空间。存储空间的优化意味着更好的IO和内存使用率,同时也节省了MIS系统内部网络的数据交换带来的带宽压力。
4
总结
经过一系列优化后,一次服务调用主要包含了UDF的处理、数据库调用、IO操作和少量MQL的执行。
服务层CPU分布(如下图所示)大致呈现为:没有明显的CPU热点,同时尽可能地保证了低延迟、高吞吐。
图11 服务层CPU时间分布
(点击可查看大图)
最终,我们在2021年九月达到了稳定的QPS/IPS目标。如下图所示,在Cache Miss Rate为3%左右的场景下,约有51K QPS的请求打到MQL服务层,即到下层数据库的访问有510K IPS的吞吐量,而P95的响应时间控制在50ms左右。
图12 50K QPS即510K IPS吞吐量
(点击可查看大图)
参考链接:
[1]https://en.wikipedia.org/wiki/Change_data_capture
[2]https://calcite.apache.org/
[3]https://developers.google.com/protocol-buffers
亿优百倍
“亿优百倍”系列文总共3篇,分享了eBay智能营销部门工作中在营销商品数据服务系统的架构、设计、代码方面的一些理解和研究,到现在就正式收官啦🎉🎉🎉!希望能给大家带来一些启发,也欢迎大家一起探讨。
精彩回顾:
亿优百倍|商品数据服务缓存与代码优化
亿优百倍|商品数据服务TiDB性能优化
亿优百倍|商品数据服务百倍性能优化之路
点击阅读原文,一键投递
eBay大量优质职位虚席以待
我们的身边,还缺一个你